import itertools
import re
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Protocol, TypeAlias


@dataclass(frozen=True, eq=True)
class ErrorPosition:
    line: int
    column: int


@dataclass(frozen=True, eq=True)
class Error:
    type: str
    msg: str
    file: str
    position: ErrorPosition

    @property
    def line(self) -> int:
        return self.position.line

    @property
    def column(self) -> int:
        return self.position.column


_TYPES = '|'.join(['TypeError', 'SyntaxError', 'Error', 'Warning'])
_ERROR_PATTERN = re.compile(r'((%s):.*) \[(\S+):(\d+):(\d+)\]' % (_TYPES))


def _parse_error_line(line: str):
    if m := _ERROR_PATTERN.match(line):
        g = m.groups()
        return Error(
            type=str(g[1]),
            msg=str(g[0]),
            file=str(g[2]),
            position=ErrorPosition(
                line=int(g[3]),
                column=int(g[4]),
            ),
        )
    return None


def _parse_errors(compile_out: IO[str]) -> Iterator[Error]:
    for line in compile_out:
        if error := _parse_error_line(line):
            yield error


@dataclass(frozen=True, eq=True)
class ErrorLabel:
    id: str
    position: ErrorPosition

    @property
    def line(self) -> int:
        return self.position.line

    @property
    def column(self) -> int:
        return self.position.column

    def __str__(self):
        return f'{self.id}:{self.line}:{self.column}'


_LABEL_PATTERN = re.compile(rb'/\* @@ (\S+) \*/')


def _parse_labels(source: Sequence[bytes]) -> Iterator[ErrorLabel]:
    for n, line in enumerate(source):
        for iter in _LABEL_PATTERN.finditer(line):
            yield ErrorLabel(
                id=bytes(iter.group(1)).decode(),
                position=ErrorPosition(
                    line=n + 1,
                    column=iter.end(0) + 1,
                ),
            )


_START_ERRORS = re.compile(rb'/\* @@[@?]')


def _find_errors_position(source: bytes) -> int | None:
    for iter in _START_ERRORS.finditer(source):
        return iter.start()
    return None


@dataclass(frozen=True, eq=True)
class EtsTestSource:
    file: Path
    source: bytes
    labels: list[ErrorLabel]
    error_position: int | None


def _parse_source(file: Path) -> EtsTestSource:
    source = file.read_bytes()
    return EtsTestSource(
        file=file,
        source=source,
        labels=list(_parse_labels(source.splitlines())),
        error_position=_find_errors_position(source),
    )


EtsErrors: TypeAlias = dict[str, list[Error]]


@dataclass(frozen=True, eq=True)
class CompiledEtsTest:
    source: EtsTestSource
    errors: EtsErrors

    @property
    def file(self):
        return self.source.file


def parse(file: Path, compile_output: IO[str]):
    return CompiledEtsTest(
        source=_parse_source(file),
        errors={
            str(k): list(v)
            for k, v in itertools.groupby(
                _parse_errors(compile_output),
                lambda e: e.file,
            )
        },
    )


@dataclass(frozen=True, eq=True)
class ErrorOutPosition:
    file: str | None
    line: int
    column: int

    def __str__(self):
        file = f'{self.file}:' if self.file else ''
        return f'? {file}{self.line}:{self.column}'


@dataclass(frozen=True, eq=True)
class ErrorOutLabel:
    label: str

    def __str__(self):
        return f'@ {self.label}'


@dataclass(frozen=True, eq=True)
class ErrorOut:
    position: ErrorOutPosition | ErrorOutLabel
    type: str
    msg: str

    def __str__(self):
        type_replace = {
            'Warning': 'Warning',
        }
        msg_type = type_replace.get(self.type, 'Error')
        return f'/* @@{self.position!s} {msg_type} {self.msg} */'


class ErrorRender(Protocol):
    def __str__(self) -> str: ...


@dataclass(frozen=True, eq=True)
class ErrorsWithLabels:
    errors: list[ErrorRender]
    labels_not_found: set[ErrorLabel]


def _error_out(error: Error, print_file: bool) -> ErrorOut:
    return ErrorOut(
        msg=error.msg,
        type=error.type,
        position=ErrorOutPosition(
            file=error.file if print_file else None,
            line=error.line,
            column=error.column,
        ),
    )


def _map_labels(test: CompiledEtsTest) -> ErrorsWithLabels:
    errors_result: list[ErrorRender] = []
    labels = {label.position: label for label in test.source.labels}
    labels_found: set[str] = set()

    print_filename = len([fn for fn in test.errors.keys() if fn != test.file.name]) > 0

    for filename, errors in test.errors.items():
        if filename != test.file.name:
            errors_result += [_error_out(er, print_filename) for er in errors]
        else:
            for error in errors:
                pos: ErrorOutPosition | ErrorOutLabel
                if label := labels.get(error.position):
                    pos = ErrorOutLabel(
                        label=label.id,
                    )
                    labels_found.add(label.id)
                else:
                    pos = ErrorOutPosition(
                        file=test.file.name if print_filename else None,
                        line=error.line,
                        column=error.column,
                    )
                errors_result.append(
                    ErrorOut(
                        type=error.type,
                        position=pos,
                        msg=error.msg,
                    )
                )
        errors_result.append('')
    if errors_result:
        errors_result.pop()
    return ErrorsWithLabels(
        errors=errors_result,
        labels_not_found={label for label in test.source.labels if label.id not in labels_found},
    )


def replace_errors(test: CompiledEtsTest):
    with_labels = _map_labels(test)
    epos = test.source.error_position
    test_data = test.source.source if epos is None else test.source.source[:epos]
    errors_data = b''.join([f'{er!s}\n'.encode() for er in with_labels.errors])
    return test_data + errors_data, with_labels.labels_not_found


def update(file: Path, compile_output: IO[str]) -> tuple[bool, set[ErrorLabel]]:
    test = parse(file, compile_output)
    new_source, labels_not_found = replace_errors(test)
    if test.source.source == new_source:
        return False, labels_not_found
    file.write_bytes(new_source)
    return True, labels_not_found
